Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645db217fa | ||
|
|
cd961cf718 | ||
|
|
c0214b233b | ||
|
|
d054d1b246 | ||
|
|
cd5bb94223 | ||
|
|
178ce486c2 | ||
|
|
811c6c482d | ||
|
|
59cd9b524c | ||
|
|
28e55a0615 | ||
|
|
5c5cd1d5da | ||
|
|
377e5d0b97 | ||
|
|
648a5932f8 | ||
|
|
b54f3fc622 | ||
|
|
63bb41425a | ||
|
|
9a2c677153 | ||
|
|
4a9a037cd8 | ||
|
|
67b06c0a10 | ||
|
|
368f9d2d6a | ||
|
|
651db50a93 | ||
|
|
decf9f5d90 | ||
|
|
f921f6bf2b | ||
|
|
abf52be7e0 | ||
|
|
3e379ce129 | ||
|
|
c38c80753a | ||
|
|
4667e0abf9 | ||
|
|
7f78f556f3 | ||
|
|
e8407ce35e | ||
|
|
188f035333 | ||
|
|
3646e6a4aa | ||
|
|
f3f5fa5e92 | ||
|
|
3a750822f6 | ||
|
|
06ff11d563 | ||
|
|
f0a0515f28 | ||
|
|
4758d5d8a4 | ||
|
|
64390f8ad7 | ||
|
|
c4caa9021e | ||
|
|
a64f33fe43 | ||
|
|
cd50626b88 | ||
|
|
7b09a076b2 | ||
|
|
91614fc545 | ||
|
|
c77f90f6b6 | ||
|
|
4f1a3260a5 | ||
|
|
1af43ef052 | ||
|
|
d5a73a7dad | ||
|
|
578916bc58 | ||
|
|
ae8b9d8fe6 | ||
|
|
50aae790b1 | ||
|
|
ea0f6d98a9 |
No files matched your search
@@ -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 }
|
|
||||||
@@ -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
|
||||||
@@ -94,6 +95,7 @@ set(APP_SOURCES
|
|||||||
source/game/scenes/title_scene.cpp
|
source/game/scenes/title_scene.cpp
|
||||||
source/game/scenes/intro_scene.cpp
|
source/game/scenes/intro_scene.cpp
|
||||||
source/game/scenes/game_scene.cpp
|
source/game/scenes/game_scene.cpp
|
||||||
|
source/game/scenes/room_fsm.cpp
|
||||||
source/game/entities/player.cpp
|
source/game/entities/player.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
source/game/map/tilemap.cpp
|
source/game/map/tilemap.cpp
|
||||||
@@ -203,6 +205,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>
|
||||||
@@ -353,4 +375,28 @@ if(BUILD_TESTS)
|
|||||||
add_test(NAME tilemap_writer
|
add_test(NAME tilemap_writer
|
||||||
COMMAND test_tilemap_writer
|
COMMAND test_tilemap_writer
|
||||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||||
|
|
||||||
|
# Smoke de la FSM de sala (RoomFsm). Cobreix loadConfig (timings i
|
||||||
|
# corbes des de YAML), advance (transicions entre estats), i
|
||||||
|
# formatRoomTitle (locale + substitució `{n}`). Stand-alone: no
|
||||||
|
# arrossega Audio/Screen/Player/EntityManager — només els helpers
|
||||||
|
# purs de la FSM, Locale i UiConfig.
|
||||||
|
add_executable(test_game_fsm
|
||||||
|
tests/test_game_fsm.cpp
|
||||||
|
source/game/scenes/room_fsm.cpp
|
||||||
|
source/core/locale/locale.cpp
|
||||||
|
source/core/ui/ui_config.cpp
|
||||||
|
source/core/resources/resource_helper.cpp
|
||||||
|
source/core/resources/resource_pack.cpp
|
||||||
|
source/utils/easing.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_game_fsm PRIVATE
|
||||||
|
"${CMAKE_SOURCE_DIR}/source"
|
||||||
|
"${CMAKE_BINARY_DIR}"
|
||||||
|
)
|
||||||
|
target_link_libraries(test_game_fsm PRIVATE SDL3::SDL3)
|
||||||
|
target_compile_options(test_game_fsm PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
add_test(NAME game_fsm
|
||||||
|
COMMAND test_game_fsm
|
||||||
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||||
endif()
|
endif()
|
||||||
@@ -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,6 +4,18 @@ sprite:
|
|||||||
path: door.gif
|
path: door.gif
|
||||||
size: [48, 32]
|
size: [48, 32]
|
||||||
tile_size: [3, 2]
|
tile_size: [3, 2]
|
||||||
|
collision:
|
||||||
|
# Caixa de col·lisió: 16×24 centrada al sprite 48×32. Coincideix amb el
|
||||||
|
# tile central transitable (els 16 px del passadís de sortida) i es
|
||||||
|
# restringeix a la franja del mur trasser (24 px d'alçada). Així
|
||||||
|
# `checkPlayerCollisions` només dispara `onPlayerContact` quan el
|
||||||
|
# jugador entra al forat real, no als marcs flanquejants ni als 8 px
|
||||||
|
# superiors que queden tapats pel HUD.
|
||||||
|
size: [16, 24]
|
||||||
|
offset: [16, 8]
|
||||||
|
# Porta = mur infranquejable. Igual que el sarc, alçada efectiva
|
||||||
|
# infinita per impedir que un jugador la salte per damunt.
|
||||||
|
height: 999
|
||||||
flags:
|
flags:
|
||||||
collides: true
|
collides: true
|
||||||
interactable: true
|
interactable: true
|
||||||
|
|||||||
|
After Width: | Height: | Size: 306 B |
@@ -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 }
|
||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -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
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ hud:
|
|||||||
pyramid: "Piramide"
|
pyramid: "Piramide"
|
||||||
room: "Sala"
|
room: "Sala"
|
||||||
|
|
||||||
|
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
|
||||||
|
# `room_intro` accepta el placeholder `{n}` que es substitueix amb
|
||||||
|
# l'índex de sala actual; veure `GameScene::formatRoomTitle`.
|
||||||
|
game:
|
||||||
|
room_intro: "Sala {n}"
|
||||||
|
room_complete: "Fase completada"
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
titles:
|
titles:
|
||||||
root: "Menú de servei"
|
root: "Menú de servei"
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ hud:
|
|||||||
pyramid: "Pyramid"
|
pyramid: "Pyramid"
|
||||||
room: "Room"
|
room: "Room"
|
||||||
|
|
||||||
|
# Room transition texts (GameScene FSM intro/outro). The `room_intro`
|
||||||
|
# string accepts a `{n}` placeholder that is replaced with the current
|
||||||
|
# room index; see `GameScene::formatRoomTitle`.
|
||||||
|
game:
|
||||||
|
room_intro: "Room {n}"
|
||||||
|
room_complete: "Stage complete"
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
titles:
|
titles:
|
||||||
root: "Service menu"
|
root: "Service menu"
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ geometry:
|
|||||||
hud_h: 24
|
hud_h: 24
|
||||||
back_wall_h: 24
|
back_wall_h: 24
|
||||||
side_wall_w: 24
|
side_wall_w: 24
|
||||||
|
# Columna central del tile transitable de la porta. La porta es renderitza
|
||||||
|
# com a sprite 48×32 (3 tiles d'amplada) centrat sobre aquesta columna; els
|
||||||
|
# 16 px centrals (just aquesta columna) són el "forat" del mur trasser per
|
||||||
|
# on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent
|
||||||
|
# mur visible. El spawn dels jugadors també s'alinea amb aquesta columna.
|
||||||
|
door_col: 10
|
||||||
|
|
||||||
data:
|
data:
|
||||||
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||||
@@ -123,8 +129,10 @@ entities:
|
|||||||
- type: sarcophagus
|
- type: sarcophagus
|
||||||
sarc_position: [3, 4]
|
sarc_position: [3, 4]
|
||||||
- type: door
|
- type: door
|
||||||
position: [9, 1]
|
# Sense `position`: la porta sempre viu al mur trasser. La columna
|
||||||
- type: momia
|
# transitable la dicta `geometry.door_col`; el render i la caixa
|
||||||
|
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
|
||||||
|
- type: mummy
|
||||||
position: [10, 7]
|
position: [10, 7]
|
||||||
- type: momia
|
- type: mummy
|
||||||
position: [7, 9]
|
position: [7, 9]
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Configuració de la GameScene: timings i corbes de la FSM de sala
|
||||||
|
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
|
||||||
|
# i font del text es reutilitzen del tema UI (data/ui.yaml > pause +
|
||||||
|
# notification). Si manca el fitxer o alguna clau, GameScene cau als
|
||||||
|
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
|
||||||
|
|
||||||
|
room_intro:
|
||||||
|
fade_in_ms: 500 # negre opac → món visible
|
||||||
|
text_delay_ms: 500 # espera amb el món net abans de sortir el text
|
||||||
|
text_enter_ms: 500 # text esquerra → centre
|
||||||
|
text_hold_ms: 1000 # text quiet al centre
|
||||||
|
text_exit_ms: 500 # text centre → fora per l'esquerra
|
||||||
|
|
||||||
|
room_outro:
|
||||||
|
text_enter_ms: 500
|
||||||
|
text_hold_ms: 1000
|
||||||
|
text_exit_ms: 500
|
||||||
|
fade_out_ms: 500 # món visible → negre opac
|
||||||
|
|
||||||
|
text:
|
||||||
|
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
|
||||||
|
# `data/ui.yaml`). El text entra suau i surt accelerant.
|
||||||
|
enter_curve: outCubic
|
||||||
|
exit_curve: inCubic
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
params.frame_width = static_cast<float>(sz[0].get_value<int>());
|
||||||
|
params.frame_height = static_cast<float>(sz[1].get_value<int>());
|
||||||
}
|
}
|
||||||
if (yaml.contains("frameHeight")) {
|
|
||||||
params.frame_height = static_cast<float>(yaml["frameHeight"].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};
|
|
||||||
};
|
};
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "game/entities/entity_factory.hpp"
|
#include "game/entities/entity_factory.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"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -65,21 +66,34 @@ void Door::update(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Door::onPlayerContact(Player* player) {
|
void Door::onPlayerContact(Player* player) {
|
||||||
if (state_ != State::OPEN || level_complete_emitted_) { return; }
|
// Només la porta OPEN despawna jugadors. CLOSED/OPENING ignoren el
|
||||||
|
// contacte (i a més, en CLOSED, el `Map::collides` ja bloqueja el
|
||||||
|
// moviment, així que el contacte no es dóna en condicions normals).
|
||||||
|
if (state_ != State::OPEN || player == nullptr || player->isExited()) { return; }
|
||||||
|
// Marquem el jugador com a EXITED i publiquem l'event d'agregació.
|
||||||
|
// El check `isExited` de la guarda anterior garanteix que només
|
||||||
|
// emetem l'event en la primera transició — `EntityManager` compta
|
||||||
|
// emissions per decidir quan publicar `level_complete`.
|
||||||
|
player->exit();
|
||||||
if (bus_ != nullptr) {
|
if (bus_ != nullptr) {
|
||||||
bus_->publish(Event{
|
bus_->publish(Event{
|
||||||
.name = "level_complete",
|
.name = "player_exited",
|
||||||
.payload = std::monostate{},
|
.payload = std::monostate{},
|
||||||
.player_id = (player != nullptr) ? player->getId() : -1,
|
.player_id = player->getId(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
level_complete_emitted_ = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Door::open() {
|
void Door::open() {
|
||||||
if (state_ != State::CLOSED) { return; }
|
if (state_ != State::CLOSED) { return; }
|
||||||
state_ = State::OPENING;
|
state_ = State::OPENING;
|
||||||
setAnimation(anim_opening_);
|
setAnimation(anim_opening_);
|
||||||
|
// Marquem el passadís com a transitable: a partir d'aquest tic
|
||||||
|
// `Map::collides` deixa de bloquejar el rect del passadís. La
|
||||||
|
// transició d'animació OPENING → OPEN encara dura el seu temps,
|
||||||
|
// però la geometria ja és permeable — coherent amb el feel de
|
||||||
|
// "la porta s'obri i el jugador pot avançar al moment".
|
||||||
|
Map::door_open = true;
|
||||||
// Edge case: si `opening` té ≤1 frame i és loop=true, mai es
|
// Edge case: si `opening` té ≤1 frame i és loop=true, mai es
|
||||||
// completarà. Per força, forcem loop=false del frame únic aquí. Els
|
// completarà. Per força, forcem loop=false del frame únic aquí. Els
|
||||||
// placeholders defineixen `loop: false` explícitament al YAML, així
|
// placeholders defineixen `loop: false` explícitament al YAML, així
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
#include "game/entities/interactable.hpp"
|
#include "game/entities/interactable.hpp"
|
||||||
|
|
||||||
// Porta de sortida del nivell. Tres estats:
|
// Porta de sortida del nivell. Tres estats:
|
||||||
// CLOSED → inicial; bloqueja el pas.
|
// CLOSED → inicial; el passadís de Map bloqueja el pas via `Map::collides`.
|
||||||
// OPENING → animació d'obertura en curs. Quan el component acaba →
|
// OPENING → animació d'obertura en curs. Quan el component acaba →
|
||||||
// transita a OPEN.
|
// transita a OPEN. El passadís ja és transitable (s'allibera
|
||||||
// OPEN → accessible. Al contacte amb el jugador publica
|
// a `open()` amb `Map::door_open = true`).
|
||||||
// `level_complete` al bus (si n'hi ha).
|
// OPEN → accessible. Al contacte amb un jugador, crida `Player::exit()`
|
||||||
|
// — el jugador queda despawnat i emet `player_exited` al bus.
|
||||||
|
// L'agregació "tots els jugadors han eixit → `level_complete`"
|
||||||
|
// viu a EntityManager.
|
||||||
//
|
//
|
||||||
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
|
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
|
||||||
// oberts) via `open()`. L'animació `opening` es fa servir com a
|
// oberts) via `open()`. L'animació `opening` es fa servir com a
|
||||||
@@ -38,7 +41,6 @@ class Door : public Interactable {
|
|||||||
void setAnimation(const AnimationComponent& anim);
|
void setAnimation(const AnimationComponent& anim);
|
||||||
|
|
||||||
State state_{State::CLOSED};
|
State state_{State::CLOSED};
|
||||||
bool level_complete_emitted_{false};
|
|
||||||
|
|
||||||
// Les 3 animacions precalculades al ctor des del YAML. L'activa
|
// Les 3 animacions precalculades al ctor des del YAML. L'activa
|
||||||
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
|
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -306,4 +306,3 @@ void Mummy::onPlayerContact(Player* player) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
REGISTER_ENTITY("mummy", Mummy)
|
REGISTER_ENTITY("mummy", Mummy)
|
||||||
REGISTER_ENTITY_AS("momia", Mummy, Momia)
|
|
||||||
@@ -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};
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -32,11 +32,37 @@ EntityManager::EntityManager() {
|
|||||||
const auto* tile = std::get_if<TilePos>(&ev.payload);
|
const auto* tile = std::get_if<TilePos>(&ev.payload);
|
||||||
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
|
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscripció a `player_exited` (publicat per Door en travessar la
|
||||||
|
// porta oberta amb un jugador): comptem una vegada per jugador i,
|
||||||
|
// quan tots els jugadors actius han eixit, emetem `level_complete`
|
||||||
|
// — l'event d'agregat que GameScene escolta per disparar la
|
||||||
|
// transició FSM a ROOM_OUTRO. La idempotència de Door (check
|
||||||
|
// `isExited`) garanteix que cada jugador suma com a màxim un cop.
|
||||||
|
exit_sub_id_ = bus_.subscribe("player_exited", [this](const Event& /*ev*/) {
|
||||||
|
++exited_players_;
|
||||||
|
if (level_complete_emitted_) { return; }
|
||||||
|
if (expected_players_ > 0 && exited_players_ >= expected_players_) {
|
||||||
|
level_complete_emitted_ = true;
|
||||||
|
bus_.publish(Event{
|
||||||
|
.name = "level_complete",
|
||||||
|
.payload = std::monostate{},
|
||||||
|
.player_id = -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
EntityManager::~EntityManager() {
|
EntityManager::~EntityManager() {
|
||||||
bus_.unsubscribe(sarc_sub_id_);
|
bus_.unsubscribe(sarc_sub_id_);
|
||||||
bus_.unsubscribe(tile_sub_id_);
|
bus_.unsubscribe(tile_sub_id_);
|
||||||
|
bus_.unsubscribe(exit_sub_id_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EntityManager::setExpectedPlayerCount(int n) {
|
||||||
|
expected_players_ = n;
|
||||||
|
exited_players_ = 0;
|
||||||
|
level_complete_emitted_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
||||||
@@ -64,7 +90,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) {
|
||||||
|
|||||||
@@ -90,10 +90,27 @@ class EntityManager {
|
|||||||
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
|
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
|
||||||
void loadFromLevel(const std::string& level_yaml_path);
|
void loadFromLevel(const std::string& level_yaml_path);
|
||||||
|
|
||||||
|
// Reset complet del nivell: destrueix totes les entitats, reseteja
|
||||||
|
// els comptadors interns (sarcòfags oberts, total, exited, flag de
|
||||||
|
// level_complete) i torna a cridar `loadFromLevel` per regenerar les
|
||||||
|
// entitats des del YAML. El bus i les seues subscripcions
|
||||||
|
// sobreviuen — això inclou les subscripcions externes (paint_layer_,
|
||||||
|
// hud_, players, GameScene). Cridat des de GameScene a la transició
|
||||||
|
// ROOM_OUTRO → ROOM_INTRO. `setExpectedPlayerCount` cal cridar-lo de
|
||||||
|
// nou després si vols agregació de level_complete al nou cicle.
|
||||||
|
void reload(const std::string& level_yaml_path);
|
||||||
|
|
||||||
|
// Fixa quants jugadors actius es consideren al recompte d'EXITED.
|
||||||
|
// Cridat per GameScene un cop instanciats els jugadors. Si manca,
|
||||||
|
// `expected_players_ = 0` i mai s'emet `level_complete` automàticament.
|
||||||
|
// Cridar-lo de nou al respawn (futur reload) reseteja el comptador.
|
||||||
|
void setExpectedPlayerCount(int n);
|
||||||
|
|
||||||
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
|
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
|
||||||
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
|
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
|
||||||
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
|
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
|
||||||
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
|
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
|
||||||
|
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
EventBus bus_;
|
EventBus bus_;
|
||||||
@@ -101,6 +118,14 @@ class EntityManager {
|
|||||||
Door* door_{nullptr};
|
Door* door_{nullptr};
|
||||||
std::size_t total_sarcs_{0};
|
std::size_t total_sarcs_{0};
|
||||||
std::size_t opened_sarcs_{0};
|
std::size_t opened_sarcs_{0};
|
||||||
|
// Recompte d'`player_exited`: incrementat a la subscripció al bus,
|
||||||
|
// resetejat per `setExpectedPlayerCount`. Quan `exited_players_` arriba
|
||||||
|
// a `expected_players_` (i tots dos > 0), publica `level_complete`
|
||||||
|
// amb `player_id = -1` (event d'agregat, no atribuible a un jugador).
|
||||||
|
int expected_players_{0};
|
||||||
|
int exited_players_{0};
|
||||||
|
bool level_complete_emitted_{false};
|
||||||
EventBus::SubId sarc_sub_id_{0};
|
EventBus::SubId sarc_sub_id_{0};
|
||||||
EventBus::SubId tile_sub_id_{0};
|
EventBus::SubId tile_sub_id_{0};
|
||||||
|
EventBus::SubId exit_sub_id_{0};
|
||||||
};
|
};
|
||||||
@@ -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 || p->isExited()) { continue; }
|
||||||
|
// Filtre per altura: si el jugador està en l'aire i està per
|
||||||
|
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
|
||||||
|
// És el mecanisme del salt per esquivar momies, roques, etc.
|
||||||
|
// Quan height és 0 (entitat sense altura declarada), el filtre
|
||||||
|
// no es dispara mai i la col·lisió funciona com abans.
|
||||||
|
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
|
||||||
// Contacte actor-actor amb hit-box petita: la caixa base
|
// 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;
|
||||||
}
|
}
|
||||||
@@ -271,6 +291,34 @@ namespace {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Col·loca la porta al mur trasser segons `Map::door_col`. La porta
|
||||||
|
// viu sempre al `wall_north`; només la columna varia per nivell. El
|
||||||
|
// sprite (típicament 48×32) es centra horitzontalment sobre la
|
||||||
|
// columna transitable i s'alinea verticalment de manera que la seua
|
||||||
|
// vora inferior coincideixi amb la base del strip del mur trasser
|
||||||
|
// (= `playfield_y`). Amb sprite 48×32 i `back_wall_h = 24` això vol
|
||||||
|
// dir que els 8 px superiors del sprite queden dins del HUD i hi
|
||||||
|
// queden tapats — comportament intencional.
|
||||||
|
auto placeDoor(EntityManager& manager, std::unique_ptr<Entity> entity, const SpriteInfo& sprite) -> bool {
|
||||||
|
if (Map::door_col <= 0 || Map::tile_size <= 0) {
|
||||||
|
std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
|
||||||
|
const SDL_FRect RENDER{
|
||||||
|
.x = CENTER_X - (sprite.width / 2.0F),
|
||||||
|
.y = static_cast<float>(Map::playfield_y) - sprite.height,
|
||||||
|
.w = sprite.width,
|
||||||
|
.h = sprite.height,
|
||||||
|
};
|
||||||
|
entity->setPixelRect(RENDER);
|
||||||
|
|
||||||
|
auto* door_ptr = dynamic_cast<Door*>(entity.get());
|
||||||
|
manager.add(std::move(entity));
|
||||||
|
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
|
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
|
||||||
if (!spawn.contains("position")) {
|
if (!spawn.contains("position")) {
|
||||||
std::cerr << "EntityManager: " << type << " sense position\n";
|
std::cerr << "EntityManager: " << type << " sense position\n";
|
||||||
@@ -302,17 +350,30 @@ namespace {
|
|||||||
}
|
}
|
||||||
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
|
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
|
||||||
|
|
||||||
if (type == "door") {
|
|
||||||
auto* door_ptr = dynamic_cast<Door*>(entity.get());
|
|
||||||
manager.add(std::move(entity));
|
manager.add(std::move(entity));
|
||||||
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
|
|
||||||
} else {
|
|
||||||
manager.add(std::move(entity));
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void EntityManager::reload(const std::string& level_yaml_path) {
|
||||||
|
// Destrucció ordenada: les entitats moren aquí; les seues
|
||||||
|
// subscripcions implícites desapareixen amb la seua vida (cap d'elles
|
||||||
|
// se subscriu directament al bus — només publiquen). El bus i les
|
||||||
|
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
|
||||||
|
entities_.clear();
|
||||||
|
door_ = nullptr;
|
||||||
|
total_sarcs_ = 0;
|
||||||
|
opened_sarcs_ = 0;
|
||||||
|
// No reset de `expected_players_`: GameScene el tornarà a fixar
|
||||||
|
// (amb el mateix valor o un de nou) després del reload, com a part
|
||||||
|
// del respawn dels jugadors. Reseteig dels comptadors d'EXITED i
|
||||||
|
// del flag d'emissió perquè el cicle següent pugui reagregar net.
|
||||||
|
exited_players_ = 0;
|
||||||
|
level_complete_emitted_ = false;
|
||||||
|
|
||||||
|
loadFromLevel(level_yaml_path);
|
||||||
|
}
|
||||||
|
|
||||||
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||||
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
|
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
|
||||||
int loaded = 0;
|
int loaded = 0;
|
||||||
@@ -326,9 +387,15 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
|||||||
auto entity = buildEntity(TYPE, sprite, anchor);
|
auto entity = buildEntity(TYPE, sprite, anchor);
|
||||||
if (entity == nullptr) { continue; }
|
if (entity == nullptr) { continue; }
|
||||||
|
|
||||||
const bool OK = (TYPE == "sarcophagus")
|
bool ok = false;
|
||||||
? placeSarcophagus(*this, std::move(entity), sprite, spawn)
|
if (TYPE == "sarcophagus") {
|
||||||
: placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
|
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
|
||||||
|
} else if (TYPE == "door") {
|
||||||
|
ok = placeDoor(*this, std::move(entity), sprite);
|
||||||
|
} else {
|
||||||
|
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
|
||||||
|
}
|
||||||
|
const bool OK = ok;
|
||||||
if (OK) { ++loaded; }
|
if (OK) { ++loaded; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,22 +140,41 @@ 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) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case Cardinal::NORTH:
|
case Cardinal::NORTH:
|
||||||
@@ -254,6 +328,10 @@ void Player::update(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
||||||
|
// EXITED: jugador despawnat. No xoca amb res ni res xoca amb ell.
|
||||||
|
// També salta jugadors EXITED del check actor↔actor més avall via
|
||||||
|
// el filtre de `isExited` (vegeu el predicat any_of).
|
||||||
|
if (state_ == State::EXITED) { return false; }
|
||||||
if (map_.collides(box)) { return true; }
|
if (map_.collides(box)) { return true; }
|
||||||
if (all_players_ == nullptr) { return false; }
|
if (all_players_ == nullptr) { return false; }
|
||||||
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
|
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
|
||||||
@@ -261,20 +339,150 @@ 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 (p->isExited()) { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline d'estats i input separats; extracció a helpers empitjoraria la
|
||||||
|
// llegibilitat. La complexitat puja 1 per la guarda EXITED.
|
||||||
|
// NOLINTNEXTLINE(readability-function-cognitive-complexity)
|
||||||
void Player::tick(float delta_time) {
|
void Player::tick(float delta_time) {
|
||||||
|
// EXITED: jugador despawnat, no fa res. Cap input, cap física, cap
|
||||||
|
// bus event. Continua sent al vector de jugadors per simplicitat
|
||||||
|
// (no s'esborra) però queda completament inert fins al respawn.
|
||||||
|
if (state_ == State::EXITED) { return; }
|
||||||
|
|
||||||
// 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 +500,282 @@ 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) {
|
||||||
|
// EXITED: cap moviment ni col·lisió. Idèntic al gating de `tick`.
|
||||||
|
if (state_ == State::EXITED) { return; }
|
||||||
|
|
||||||
|
// 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 +784,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,19 +817,54 @@ 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) {
|
||||||
|
// En HURT el jugador es desplaça segons `intended_v*` (escrit a
|
||||||
|
// `enterHurt` i decremental a `tick`). Si `tryMove` no avança
|
||||||
|
// perquè topa amb un mur, el parem en sec i tornem a GROUNDED —
|
||||||
|
// honra la regla "salvo col·lisió, llavors para".
|
||||||
|
animate(delta_time);
|
||||||
|
const float FRAME_START_X = getPosX();
|
||||||
|
const float FRAME_START_Y = getPosY();
|
||||||
|
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();
|
const auto [COL, ROW] = getCurrentTile();
|
||||||
if (COL != last_tile_col_ || ROW != last_tile_row_) {
|
if (COL == last_tile_col_ && ROW == last_tile_row_) { return; }
|
||||||
last_tile_col_ = COL;
|
last_tile_col_ = COL;
|
||||||
last_tile_row_ = ROW;
|
last_tile_row_ = ROW;
|
||||||
bus_->publish(Event{
|
bus_->publish(Event{
|
||||||
@@ -359,11 +872,110 @@ void Player::applyMovement(float delta_time) {
|
|||||||
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
|
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
|
||||||
.player_id = id_,
|
.player_id = id_,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Player::render() {
|
||||||
|
// EXITED: invisible. La crida al render base es salta sencera; el
|
||||||
|
// jugador no apareix al z-sort tot i seguir vivint al vector.
|
||||||
|
if (state_ == State::EXITED) { return; }
|
||||||
|
|
||||||
|
// 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";
|
||||||
@@ -395,11 +1007,51 @@ void Player::clampToPlayfield() {
|
|||||||
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
|
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
|
||||||
const auto MAX_X = static_cast<float>(
|
const auto MAX_X = static_cast<float>(
|
||||||
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
|
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
|
||||||
const auto MIN_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
|
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset);
|
||||||
const auto MAX_Y = static_cast<float>(
|
const auto MAX_Y = static_cast<float>(
|
||||||
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
|
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
|
||||||
|
|
||||||
|
// Passadís de sortida: si la base box queda enterament dins del rang
|
||||||
|
// X del passadís de la porta, deixem que pugi al strip del mur trasser
|
||||||
|
// (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al
|
||||||
|
// playfield. La regla "enterament dins" exigeix alinear-se al passadís
|
||||||
|
// abans de pujar — la base box (12 px) hi cap amb 4 px de marge.
|
||||||
|
if (map_.door_corridor_w > 0) {
|
||||||
|
const float BASE_X = getPosX() + static_cast<float>(collision_x_offset);
|
||||||
|
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size);
|
||||||
|
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
|
||||||
|
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
|
||||||
|
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
|
||||||
|
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
|
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
|
||||||
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
|
setPosY(std::clamp(getPosY(), min_y, MAX_Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Player::respawn(float x, float y) {
|
||||||
|
state_ = State::GROUNDED;
|
||||||
|
intended_vx_ = 0.0F;
|
||||||
|
intended_vy_ = 0.0F;
|
||||||
|
input_vx_ = 0.0F;
|
||||||
|
input_vy_ = 0.0F;
|
||||||
|
being_pushed_ = false;
|
||||||
|
jump_z_ = 0.0F;
|
||||||
|
jump_vz_ = 0.0F;
|
||||||
|
landing_stun_remaining_ms_ = 0.0F;
|
||||||
|
turning_remaining_ms_ = 0.0F;
|
||||||
|
pending_cardinal_ = Cardinal::NONE;
|
||||||
|
intermediate_cardinal_ = Cardinal::NONE;
|
||||||
|
last_input_cardinal_ = Cardinal::NONE;
|
||||||
|
// Reseteig del facing al d'spawn original (mirant cap a la càmera):
|
||||||
|
// sense això, un EXITED que entrava per la porta amb facing UP
|
||||||
|
// re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor.
|
||||||
|
facing_ = Facing::DOWN;
|
||||||
|
setFlip(SDL_FLIP_NONE);
|
||||||
|
setCurrentAnimation("stand_down");
|
||||||
|
setPosX(x);
|
||||||
|
setPosY(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto Player::getBaseBox() const -> SDL_FRect {
|
auto Player::getBaseBox() const -> SDL_FRect {
|
||||||
@@ -450,12 +1102,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 FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
|
||||||
const float SAVED = (this->*get_pos)();
|
const float SAVED = (this->*get_pos)();
|
||||||
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA));
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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,33 @@ 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,
|
||||||
|
// Jugador que ha eixit per la porta de la sala. Despawn lògic:
|
||||||
|
// no es renderitza, no processa input, no es mou, no col·lisiona
|
||||||
|
// amb res ni rep contacte d'entitats. Idempotent: les crides
|
||||||
|
// posteriors a `exit()` són no-op. El reset el porta `respawn()`
|
||||||
|
// (cridada per GameScene en transició OUTRO → INTRO).
|
||||||
|
EXITED,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
|
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
|
||||||
@@ -51,6 +79,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 +118,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 +127,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 +151,79 @@ 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; }
|
||||||
|
[[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; }
|
||||||
|
// Tangible per a contactes jugador↔jugador: només els estats a terra
|
||||||
|
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
|
||||||
|
// EXITED tampoc — el jugador ha eixit lògicament i ja no és al món.
|
||||||
|
[[nodiscard]] auto isPvpTangible() const -> bool {
|
||||||
|
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
|
||||||
|
// Idempotent — només la primera crida fa la transició; les
|
||||||
|
// posteriors són no-op. La publicació del `player_exited` al bus
|
||||||
|
// la fa el caller (típicament `Door::onPlayerContact`) per
|
||||||
|
// mantenir aquesta capçalera lleugera (sense incloure event_bus).
|
||||||
|
// Definit inline perquè els consumidors de baix nivell (test
|
||||||
|
// d'entitats) puguen enllaçar Door sense arrossegar player.cpp.
|
||||||
|
void exit() {
|
||||||
|
if (state_ == State::EXITED) { return; }
|
||||||
|
state_ = State::EXITED;
|
||||||
|
intended_vx_ = 0.0F;
|
||||||
|
intended_vy_ = 0.0F;
|
||||||
|
// Important: també resetegem `input_v*` perquè el resolver
|
||||||
|
// jugador↔jugador (`resolvePlayerVsPlayer`) consulta
|
||||||
|
// `getInputVX/Y` per decidir si un jugador "es mou". Un EXITED
|
||||||
|
// amb input fantasma del moment d'entrar a la porta dispararia
|
||||||
|
// la branca head-on i bloquejaria l'altre jugador en sec.
|
||||||
|
input_vx_ = 0.0F;
|
||||||
|
input_vy_ = 0.0F;
|
||||||
|
being_pushed_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
|
||||||
|
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
|
||||||
|
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
|
||||||
|
// (es pot cridar des de qualsevol estat).
|
||||||
|
void respawn(float x, float y);
|
||||||
|
|
||||||
|
// Alçada actual del salt (píxels visuals per damunt del sòl).
|
||||||
|
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
|
||||||
|
// per altura.
|
||||||
|
[[nodiscard]] auto getJumpZ() const -> float { return jump_z_; }
|
||||||
|
|
||||||
// Injecta el bus del nivell. El Player publica `on_tile_entered` cada
|
// 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 +258,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 +303,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 +349,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 +380,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 +412,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,
|
||||||
|
|||||||
@@ -75,9 +75,24 @@ namespace Map {
|
|||||||
playfield_w = board_w + (2 * outer_corridor);
|
playfield_w = board_w + (2 * outer_corridor);
|
||||||
playfield_h = board_h + (2 * outer_corridor);
|
playfield_h = board_h + (2 * outer_corridor);
|
||||||
|
|
||||||
|
// Porta i passadís de sortida. `door_col` és la columna central
|
||||||
|
// transitable; el passadís són exactament els 16 px d'eixa columna
|
||||||
|
// dins del strip del mur trasser. Si manca la clau, `door_col=0`
|
||||||
|
// i el passadís queda a x=0 amb mida zero — pràcticament inactiu
|
||||||
|
// (el bloqueig del clamp continua sent el rectangle del playfield).
|
||||||
|
door_col = g.contains("door_col") ? g["door_col"].get_value<int>() : 0;
|
||||||
|
door_corridor_x = (door_col * tile_size) + tilemap_origin_x;
|
||||||
|
door_corridor_y = hud_h;
|
||||||
|
door_corridor_w = tile_size;
|
||||||
|
door_corridor_h = back_wall_h;
|
||||||
|
// Cada càrrega de nivell deixa la porta tancada; l'entitat Door
|
||||||
|
// canviarà el flag a true quan transiti a OPEN.
|
||||||
|
door_open = false;
|
||||||
|
|
||||||
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
|
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
|
||||||
<< " sarcs " << sarc_w << "x" << sarc_h
|
<< " sarcs " << sarc_w << "x" << sarc_h
|
||||||
<< " pitch " << pitch_x << "x" << pitch_y
|
<< " pitch " << pitch_x << "x" << pitch_y
|
||||||
|
<< " door_col " << door_col
|
||||||
<< " des de " << yaml_path << '\n';
|
<< " des de " << yaml_path << '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +114,18 @@ namespace Map {
|
|||||||
if (overlapStrict(base_box, getSarcRect(col, row))) { return true; }
|
if (overlapStrict(base_box, getSarcRect(col, row))) { return true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Porta tancada: el passadís de sortida actua com a paret. Quan la
|
||||||
|
// porta passa a OPEN, l'entitat Door posa `door_open = true` i
|
||||||
|
// aquesta clàusula deixa de bloquejar — el jugador pot creuar.
|
||||||
|
if (!door_open && door_corridor_w > 0 && door_corridor_h > 0) {
|
||||||
|
const SDL_FRect CORRIDOR{
|
||||||
|
.x = static_cast<float>(door_corridor_x),
|
||||||
|
.y = static_cast<float>(door_corridor_y),
|
||||||
|
.w = static_cast<float>(door_corridor_w),
|
||||||
|
.h = static_cast<float>(door_corridor_h),
|
||||||
|
};
|
||||||
|
if (overlapStrict(base_box, CORRIDOR)) { return true; }
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +163,10 @@ namespace Map {
|
|||||||
.tile_size = tile_size,
|
.tile_size = tile_size,
|
||||||
.tilemap_origin_x = tilemap_origin_x,
|
.tilemap_origin_x = tilemap_origin_x,
|
||||||
.tilemap_origin_y = tilemap_origin_y,
|
.tilemap_origin_y = tilemap_origin_y,
|
||||||
|
.door_corridor_x = door_corridor_x,
|
||||||
|
.door_corridor_y = door_corridor_y,
|
||||||
|
.door_corridor_w = door_corridor_w,
|
||||||
|
.door_corridor_h = door_corridor_h,
|
||||||
.collides = &collides,
|
.collides = &collides,
|
||||||
.inside_playfield = &insidePlayfield,
|
.inside_playfield = &insidePlayfield,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ namespace Map {
|
|||||||
inline int playfield_w = 0;
|
inline int playfield_w = 0;
|
||||||
inline int playfield_h = 0;
|
inline int playfield_h = 0;
|
||||||
|
|
||||||
|
// Porta de sortida del nivell. La porta sempre viu al mur trasser
|
||||||
|
// (`wall_north`); només la columna varia per nivell. `door_col` és la
|
||||||
|
// columna central transitable: el sprite 48×32 es dibuixa centrat
|
||||||
|
// sobre aquesta columna i els 16 px centrals (just `door_col`) són
|
||||||
|
// l'únic forat del mur trasser per on el jugador surt. Derivat de
|
||||||
|
// `geometry.door_col` al YAML.
|
||||||
|
inline int door_col = 0;
|
||||||
|
inline int door_corridor_x = 0;
|
||||||
|
inline int door_corridor_y = 0;
|
||||||
|
inline int door_corridor_w = 0;
|
||||||
|
inline int door_corridor_h = 0;
|
||||||
|
// Estat del passadís: si la porta està oberta, el `door_corridor` no
|
||||||
|
// bloqueja el moviment del jugador (vegeu `Map::collides`). Qui muta
|
||||||
|
// aquest flag és l'entitat `Door` quan transita CLOSED → OPEN; el
|
||||||
|
// reset el porta `Map::loadFrom` (cada càrrega de nivell el torna a
|
||||||
|
// posar a false).
|
||||||
|
inline bool door_open = false;
|
||||||
|
|
||||||
// Carrega el bloc `geometry:` del YAML i recalcula tots els derivats.
|
// Carrega el bloc `geometry:` del YAML i recalcula tots els derivats.
|
||||||
// Ha de cridar-se abans d'instanciar `Player` o consultar `collides()`:
|
// Ha de cridar-se abans d'instanciar `Player` o consultar `collides()`:
|
||||||
// GameScene ho fa al ctor.
|
// GameScene ho fa al ctor.
|
||||||
@@ -104,6 +122,13 @@ namespace Map {
|
|||||||
int tile_size{0};
|
int tile_size{0};
|
||||||
int tilemap_origin_x{0};
|
int tilemap_origin_x{0};
|
||||||
int tilemap_origin_y{0};
|
int tilemap_origin_y{0};
|
||||||
|
// Passadís de sortida (forat del mur trasser a la columna de la
|
||||||
|
// porta). El Player l'usa des del clamp per permetre pujar al
|
||||||
|
// strip del mur quan la base box queda dins del rang X.
|
||||||
|
int door_corridor_x{0};
|
||||||
|
int door_corridor_y{0};
|
||||||
|
int door_corridor_w{0};
|
||||||
|
int door_corridor_h{0};
|
||||||
bool (*collides)(const SDL_FRect&){nullptr};
|
bool (*collides)(const SDL_FRect&){nullptr};
|
||||||
bool (*inside_playfield)(const SDL_FRect&){nullptr};
|
bool (*inside_playfield)(const SDL_FRect&){nullptr};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ auto PaintLayer::isPainted(int col, int row) const -> bool {
|
|||||||
return painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0;
|
return painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PaintLayer::clear() {
|
||||||
|
// Mateixa geometria que al ctor: matriu de tiles a -1 (no pintat) i
|
||||||
|
// buffer ARGB a zero (alpha=0 = transparent → render no escriu res).
|
||||||
|
painted_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
|
||||||
|
std::ranges::fill(pixels_, 0U);
|
||||||
|
}
|
||||||
|
|
||||||
auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool {
|
auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool {
|
||||||
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
|
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
|
||||||
if (tile_index < 0) { return false; }
|
if (tile_index < 0) { return false; }
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ class PaintLayer {
|
|||||||
|
|
||||||
[[nodiscard]] auto isPainted(int col, int row) const -> bool;
|
[[nodiscard]] auto isPainted(int col, int row) const -> bool;
|
||||||
|
|
||||||
|
// Buida el pintat acumulat. Marca tots els tiles com a no pintats i
|
||||||
|
// reseteja el buffer ARGB a alpha=0 (transparent). Cridat per
|
||||||
|
// GameScene a la transició ROOM_OUTRO → ROOM_INTRO. La subscripció
|
||||||
|
// al bus es manté — el PaintLayer continuarà reaccionant a
|
||||||
|
// `on_tile_entered` un cop els jugadors hagin reaparegut.
|
||||||
|
void clear();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void blitPaintTile(int col, int row, int tile_index);
|
void blitPaintTile(int col, int row, int tile_index);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
@@ -26,7 +27,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 +95,44 @@ 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; }
|
||||||
|
// EXITED: el jugador despawnat no és tangible. Si entrés al
|
||||||
|
// resolver, la seua presència física frenaria l'altre (per
|
||||||
|
// overlap predictiu) tot i que `applyMovement` és no-op. Sortir
|
||||||
|
// d'aquí garanteix que el segon jugador pot creuar la porta
|
||||||
|
// sense interferència del primer ja eixit.
|
||||||
|
if (a->isExited() || b->isExited()) { return; }
|
||||||
|
|
||||||
|
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense
|
||||||
|
// resolver, sense head-on, sense push. Els dos continuen amb la
|
||||||
|
// velocitat que portaven. Important: això NO és el filtre per
|
||||||
|
// altura del cos — és binari. Coincideix amb la regla d'`Player::
|
||||||
|
// collidesWithWorld`: durant un salt no hi ha mai col·lisió amb
|
||||||
|
// un caminant, ni baix ni alt.
|
||||||
|
if (A_AIR != B_AIR) { return; }
|
||||||
|
|
||||||
// Calculem l'overlap previst en l'espai de la hit-box petita:
|
// 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 +149,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.
|
||||||
@@ -214,11 +270,13 @@ GameScene::GameScene(Config config)
|
|||||||
// Les surfaces de les entitats es carreguen al Resource::Cache aquí.
|
// Les surfaces de les entitats es carreguen al Resource::Cache aquí.
|
||||||
entity_manager_.loadFromLevel(config_.map_yaml);
|
entity_manager_.loadFromLevel(config_.map_yaml);
|
||||||
|
|
||||||
// Subscripcions bàsiques al bus del nivell: de moment només logs, sense
|
// Subscripcions bàsiques al bus del nivell. `level_complete` també
|
||||||
// tocar ni la vida del jugador ni l'estat de la escena. Serà la base
|
// dispara la transició a ROOM_OUTRO si encara estem a PLAYING; les
|
||||||
// sobre la qual cablejar HUD i fi de nivell més endavant.
|
// re-emissions es filtren per estat per no re-entrar mai des d'una
|
||||||
entity_manager_.getBus().subscribe("level_complete", [](const Event& ev) {
|
// transició ja en marxa.
|
||||||
|
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
|
||||||
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
|
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
|
||||||
|
if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); }
|
||||||
});
|
});
|
||||||
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
|
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
|
||||||
const auto* dmg = std::get_if<int>(&ev.payload);
|
const auto* dmg = std::get_if<int>(&ev.payload);
|
||||||
@@ -226,6 +284,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,30 +307,21 @@ 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 lligat a la porta: tots els jugadors apareixen davant del
|
||||||
// queda dins del passadís vertical del mig (entre columnes 1 i 2) i
|
// passadís de sortida. `computeSpawn` deriva les coordenades de
|
||||||
// centrada en Y al passadís exterior superior. Així no naix dins de
|
// `Map::door_col` perquè canviar la columna de la porta al YAML
|
||||||
// cap sarcòfag.
|
// moga automàticament l'spawn — no hi ha cap valor hardcoded.
|
||||||
constexpr auto CORRIDOR_CENTER_X = static_cast<float>(GameCanvas::CENTER_X);
|
const auto SPAWN = computeSpawn();
|
||||||
const auto CORRIDOR_CENTER_Y =
|
|
||||||
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
|
|
||||||
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
|
|
||||||
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
|
|
||||||
|
|
||||||
// En mode BOTH separem els jugadors un tile a banda i banda del
|
|
||||||
// centre perquè no naixen superposats. En mode single-player
|
|
||||||
// (P1 o P2 aïllats per a proves de dev) l'únic jugador arrenca al
|
|
||||||
// centre exacte.
|
|
||||||
const bool BOTH = (USE_P1 && USE_P2);
|
const bool BOTH = (USE_P1 && USE_P2);
|
||||||
const auto P1_SPAWN_X = BOTH ? SPAWN_X - static_cast<float>(Map::tile_size) : SPAWN_X;
|
|
||||||
const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast<float>(Map::tile_size) : SPAWN_X;
|
|
||||||
|
|
||||||
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
|
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
|
||||||
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
|
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
|
||||||
@@ -269,11 +333,13 @@ GameScene::GameScene(Config config)
|
|||||||
constexpr int ID_P2 = 1;
|
constexpr int ID_P2 = 1;
|
||||||
if (USE_P1) {
|
if (USE_P1) {
|
||||||
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
|
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
|
||||||
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, P1_SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
|
const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast<float>(Map::tile_size);
|
||||||
|
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, X, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
|
||||||
}
|
}
|
||||||
if (USE_P2) {
|
if (USE_P2) {
|
||||||
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
|
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
|
||||||
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, P2_SPAWN_X, SPAWN_Y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
|
const float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast<float>(Map::tile_size);
|
||||||
|
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaintLayer: font única del pintat del nivell. Es crea després de
|
// PaintLayer: font única del pintat del nivell. Es crea després de
|
||||||
@@ -299,6 +365,15 @@ GameScene::GameScene(Config config)
|
|||||||
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_); }
|
||||||
|
|
||||||
|
// Comptador d'EXITED a EntityManager: publica `level_complete` quan
|
||||||
|
// tots els jugadors actius han travessat la porta. Cap setExpectedPlayerCount
|
||||||
|
// ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí
|
||||||
|
// perquè ja sabem el nombre real d'instàncies.
|
||||||
|
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
|
||||||
|
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
|
||||||
|
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
|
||||||
|
for (auto& p : players_) { p->setPushFactor(push_factor_); }
|
||||||
|
|
||||||
// Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i
|
// 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
|
||||||
// d'aquí. Subscrit al bus per reaccionar a `player_hit`,
|
// d'aquí. Subscrit al bus per reaccionar a `player_hit`,
|
||||||
@@ -326,10 +401,209 @@ GameScene::GameScene(Config config)
|
|||||||
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
|
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
|
||||||
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
|
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
|
||||||
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
|
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
|
||||||
|
|
||||||
|
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
||||||
|
// vegada; canvis requereixen reiniciar el joc.
|
||||||
|
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::CONFIG_YAML);
|
||||||
|
|
||||||
|
// Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita
|
||||||
|
// les transicions són instantànies (sense fades ni text) i el primer
|
||||||
|
// tick d'`updateRoomTransition` ja saltarà a PLAYING.
|
||||||
|
std::cout << "[ROOM] state=ROOM_INTRO (initial)" << '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
GameScene::~GameScene() = default;
|
GameScene::~GameScene() = default;
|
||||||
|
|
||||||
|
auto GameScene::computeSpawn() -> PlayerSpawn {
|
||||||
|
// Centre de la columna del passadís en píxels canvas. La caixa de
|
||||||
|
// col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2
|
||||||
|
// px de marge a banda i banda; alinear el sprite al centre de la
|
||||||
|
// columna garanteix que la base del jugador i la del passadís
|
||||||
|
// coincideixin verticalment.
|
||||||
|
const float DOOR_CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
|
||||||
|
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
|
||||||
|
|
||||||
|
// Y: centre del corredor exterior superior (mateix valor que abans;
|
||||||
|
// el corredor és simètric respecte a `playfield_y`).
|
||||||
|
const float CORRIDOR_CENTER_Y = static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
|
||||||
|
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
|
||||||
|
|
||||||
|
// Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre.
|
||||||
|
// Els callsites individuals decideixen si usar aquests valors o si
|
||||||
|
// estem en mode single-player (un sol jugador → centrat exactament).
|
||||||
|
return {
|
||||||
|
.p1_x = CENTER_TL_X - static_cast<float>(Map::tile_size),
|
||||||
|
.p2_x = CENTER_TL_X + static_cast<float>(Map::tile_size),
|
||||||
|
.y = SPAWN_Y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::resetRoom() {
|
||||||
|
// Recarregar les entitats des del YAML del mapa: la porta torna a
|
||||||
|
// CLOSED (amb l'open=false a Map al loadFrom), els sarcs tornen a
|
||||||
|
// CLOSED amb visited buit, momies i altres actors a la posició inicial.
|
||||||
|
Map::loadFrom(config_.map_yaml);
|
||||||
|
entity_manager_.reload(config_.map_yaml);
|
||||||
|
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
|
||||||
|
if (paint_layer_) { paint_layer_->clear(); }
|
||||||
|
|
||||||
|
// Respawn dels jugadors. Recalcular spawn per si door_col canvia
|
||||||
|
// (avui el mapa no canvia entre cicles, però la lògica ja queda
|
||||||
|
// preparada per a l'itinerari futur).
|
||||||
|
const auto SPAWN = computeSpawn();
|
||||||
|
const bool BOTH = (players_.size() >= 2);
|
||||||
|
// Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x +
|
||||||
|
// tile_size, perquè SPAWN.p1_x és el centre menys un tile).
|
||||||
|
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
|
||||||
|
for (std::size_t i = 0; i < players_.size(); ++i) {
|
||||||
|
float x = SINGLE_X;
|
||||||
|
if (BOTH) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
||||||
|
players_[i]->respawn(x, SPAWN.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "[ROOM] reset: entities reloaded, paint cleared, "
|
||||||
|
<< players_.size() << " player(s) respawned\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::setState(RoomFsm::State new_state) {
|
||||||
|
if (new_state == state_) { return; }
|
||||||
|
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
|
||||||
|
const auto PREV = state_;
|
||||||
|
state_ = new_state;
|
||||||
|
transition_ = {};
|
||||||
|
// En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de
|
||||||
|
// "torna a la mateixa sala"), reseteja completament l'estat del
|
||||||
|
// nivell. Si la transició és la inicial (PREV també és ROOM_INTRO,
|
||||||
|
// imposible amb el guard de dalt) o ve d'un altre estat, no fa res.
|
||||||
|
if (new_state == RoomFsm::State::ROOM_INTRO && PREV == RoomFsm::State::ROOM_OUTRO) {
|
||||||
|
resetRoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::updateRoomTransition(int delta_ms) {
|
||||||
|
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
|
||||||
|
if (NEXT != state_) { setState(NEXT); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::renderRoomFade() {
|
||||||
|
// Fade negre sobre el món, pintat un cop els actors ja estan al
|
||||||
|
// canvas i abans del HUD. Dos casos:
|
||||||
|
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent)
|
||||||
|
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga)
|
||||||
|
int dur = 0;
|
||||||
|
float t = 0.0F;
|
||||||
|
bool fade_in = false;
|
||||||
|
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase == 0) {
|
||||||
|
dur = transition_config_.intro_fade_in_ms;
|
||||||
|
fade_in = true;
|
||||||
|
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 3) {
|
||||||
|
dur = transition_config_.outro_fade_out_ms;
|
||||||
|
fade_in = false;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
t = (dur > 0)
|
||||||
|
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur), 0.0F, 1.0F)
|
||||||
|
: 1.0F;
|
||||||
|
const float ALPHA_F = fade_in ? (1.0F - t) : t;
|
||||||
|
const auto ALPHA = static_cast<std::uint8_t>(255.0F * ALPHA_F);
|
||||||
|
if (ALPHA == 0) { return; }
|
||||||
|
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::renderRoomText() {
|
||||||
|
// Text de la transició de sala. Reusa la font cachejada per a la
|
||||||
|
// pausa (mateix tipus de lletra, mateix índex de color), i fa el
|
||||||
|
// mateix patró esquerra → centre → esquerra que el text de pausa
|
||||||
|
// però amb les durades pròpies de la transició.
|
||||||
|
//
|
||||||
|
// Mapatge per estat (només els estats amb text actiu hi entren):
|
||||||
|
// ROOM_INTRO: phases 2..4 → enter / hold / exit; text = sala N
|
||||||
|
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; text = "fase completada"
|
||||||
|
if (!pause_font_) { return; }
|
||||||
|
|
||||||
|
std::string text;
|
||||||
|
int phase_enter = 0;
|
||||||
|
int phase_exit = 0;
|
||||||
|
int dur_enter = 0;
|
||||||
|
int dur_exit = 0;
|
||||||
|
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
|
||||||
|
text = RoomFsm::formatRoomTitle(room_index_);
|
||||||
|
phase_enter = 2;
|
||||||
|
phase_exit = 4;
|
||||||
|
dur_enter = transition_config_.intro_text_enter_ms;
|
||||||
|
dur_exit = transition_config_.intro_text_exit_ms;
|
||||||
|
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
|
||||||
|
text = Locale::get().text("game.room_complete");
|
||||||
|
phase_enter = 0;
|
||||||
|
phase_exit = 2;
|
||||||
|
dur_enter = transition_config_.outro_text_enter_ms;
|
||||||
|
dur_exit = transition_config_.outro_text_exit_ms;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int KERNING = 0;
|
||||||
|
const int W = pause_font_->length(text, KERNING);
|
||||||
|
// El text entra des de l'esquerra (off-screen amb el seu cantó dret
|
||||||
|
// a x=0) i, després del hold, surt cap a la dreta (off-screen amb
|
||||||
|
// el seu cantó esquerre a x=WIDTH). Així el text "travessa" la
|
||||||
|
// pantalla en una sola direcció en compte de tornar enrere.
|
||||||
|
const int OFF_LEFT = -W;
|
||||||
|
const int OFF_RIGHT = GameCanvas::WIDTH;
|
||||||
|
int x = GameCanvas::CENTER_X;
|
||||||
|
|
||||||
|
if (transition_.phase == phase_enter) {
|
||||||
|
const float T = (dur_enter > 0)
|
||||||
|
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||||||
|
: 1.0F;
|
||||||
|
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||||||
|
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
|
||||||
|
} else if (transition_.phase == phase_exit) {
|
||||||
|
const float T = (dur_exit > 0)
|
||||||
|
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||||||
|
: 1.0F;
|
||||||
|
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||||||
|
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr Uint8 OUTLINE_WHITE = 255;
|
||||||
|
constexpr Uint8 STROKE_DIST = 1;
|
||||||
|
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
|
||||||
|
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
|
||||||
|
x,
|
||||||
|
Y,
|
||||||
|
text,
|
||||||
|
KERNING,
|
||||||
|
UiConfig::get().notificationText(),
|
||||||
|
STROKE_DIST,
|
||||||
|
OUTLINE_WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
|
||||||
|
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
|
||||||
|
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
|
||||||
|
// constant. La caixa de render es centra horitzontalment al peu i
|
||||||
|
// s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra
|
||||||
|
// quede pintada per davant del polvet i el polvet, en l'y-sort,
|
||||||
|
// entre per davant del jugador (mateixa lògica que la sombra).
|
||||||
|
constexpr float DUST_W = 40.0F;
|
||||||
|
constexpr float DUST_H = 16.0F;
|
||||||
|
auto dust = std::make_unique<Dust>(EntityLoader::loadTemplate("dust"));
|
||||||
|
dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP);
|
||||||
|
dust->setSurface(Resource::Cache::get().getSurface("dust.gif"));
|
||||||
|
// Recolzat al sòl, amb la base del rect 1 px per sobre del peu del
|
||||||
|
// jugador → en l'y-sort surt amb feet_y un pèl menor i, per
|
||||||
|
// estabilitat de stable_sort, queda darrere del jugador.
|
||||||
|
dust->setPixelRect({
|
||||||
|
.x = feet_x_center - (DUST_W * 0.5F),
|
||||||
|
.y = feet_y - DUST_H - 1.0F,
|
||||||
|
.w = DUST_W,
|
||||||
|
.h = DUST_H,
|
||||||
|
});
|
||||||
|
entity_manager_.add(std::move(dust));
|
||||||
|
}
|
||||||
|
|
||||||
void GameScene::handlePauseInput() {
|
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.
|
||||||
@@ -423,6 +697,11 @@ void GameScene::update(int delta_ms) {
|
|||||||
handlePauseInput();
|
handlePauseInput();
|
||||||
updatePauseTransition(delta_ms);
|
updatePauseTransition(delta_ms);
|
||||||
|
|
||||||
|
// Transició de sala: viu en temps real (no escalada per la pausa)
|
||||||
|
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING
|
||||||
|
// arrenque ja sigui efectiu en el mateix frame.
|
||||||
|
updateRoomTransition(delta_ms);
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
||||||
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
||||||
@@ -446,9 +725,15 @@ void GameScene::update(int delta_ms) {
|
|||||||
constexpr bool EDITOR_FROZEN = false;
|
constexpr bool EDITOR_FROZEN = false;
|
||||||
#endif
|
#endif
|
||||||
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
|
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
|
||||||
|
const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING);
|
||||||
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
|
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
|
||||||
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
|
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
|
||||||
|
|
||||||
|
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
|
||||||
|
// poden col·lisionar amb entitats. Les entitats sí, perquè el
|
||||||
|
// disseny demana que el món continui animant-se durant les
|
||||||
|
// transicions de sala.
|
||||||
|
if (GAMEPLAY_ACTIVE) {
|
||||||
// Pipeline de moviment en tres passes per poder intercalar la
|
// Pipeline de moviment en tres passes per poder intercalar la
|
||||||
// resolució de contactes jugador↔jugador entre input i posició:
|
// resolució de contactes jugador↔jugador entre input i posició:
|
||||||
// 1) tick: cada jugador calcula la velocitat intencionada
|
// 1) tick: cada jugador calcula la velocitat intencionada
|
||||||
@@ -462,6 +747,7 @@ void GameScene::update(int delta_ms) {
|
|||||||
for (auto& player : players_) { player->tick(DT); }
|
for (auto& player : players_) { player->tick(DT); }
|
||||||
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
|
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
|
||||||
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
|
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
|
||||||
|
}
|
||||||
|
|
||||||
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
|
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
|
||||||
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
|
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
|
||||||
@@ -472,8 +758,10 @@ void GameScene::update(int delta_ms) {
|
|||||||
// Detecció de contacte player↔entitat després que totes les entitats
|
// Detecció de contacte player↔entitat després que totes les entitats
|
||||||
// hagin mogut aquest frame. Reutilitzem la vista plana que ja
|
// hagin mogut aquest frame. Reutilitzem la vista plana que ja
|
||||||
// manté el GameScene per al resolver player↔player.
|
// manté el GameScene per al resolver player↔player.
|
||||||
|
if (GAMEPLAY_ACTIVE) {
|
||||||
entity_manager_.checkPlayerCollisions(players_view_);
|
entity_manager_.checkPlayerCollisions(players_view_);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
editor_.update(delta_ms, tilemap_);
|
editor_.update(delta_ms, tilemap_);
|
||||||
@@ -511,6 +799,19 @@ 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) {
|
||||||
|
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
|
||||||
|
// Les entitats sí que es renderitzen perquè el disseny demana que
|
||||||
|
// el món continui animant-se durant el fade i el text.
|
||||||
|
const bool RENDER_PLAYERS = (state_ == RoomFsm::State::PLAYING);
|
||||||
|
|
||||||
|
if (RENDER_PLAYERS) {
|
||||||
|
// Passada de sombres dels jugadors saltant. Va per davant del
|
||||||
|
// pintat (al damunt) però per darrere de TOTS els actors —
|
||||||
|
// entitats i jugadors, inclòs el saltador mateix— així la sombra
|
||||||
|
// queda al sòl sense tapar res. `renderShadow` és no-op si el
|
||||||
|
// jugador està a terra, així que no cal filtrar al callsite.
|
||||||
|
for (const auto& p : players_) { p->renderShadow(); }
|
||||||
|
}
|
||||||
// Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
|
// 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
|
||||||
@@ -521,14 +822,16 @@ void GameScene::render() {
|
|||||||
Player* player; // nullable; si és nullptr, s'usa `entity`.
|
Player* player; // nullable; si és nullptr, s'usa `entity`.
|
||||||
};
|
};
|
||||||
std::vector<Renderable> order;
|
std::vector<Renderable> order;
|
||||||
order.reserve(entity_manager_.entityCount() + players_.size());
|
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0));
|
||||||
const auto ENTITIES = entity_manager_.getEntities();
|
const auto ENTITIES = entity_manager_.getEntities();
|
||||||
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
|
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
|
||||||
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
|
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
|
||||||
});
|
});
|
||||||
|
if (RENDER_PLAYERS) {
|
||||||
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
|
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
|
||||||
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
|
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
|
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
|
||||||
return a.feet_y < b.feet_y;
|
return a.feet_y < b.feet_y;
|
||||||
});
|
});
|
||||||
@@ -539,11 +842,21 @@ void GameScene::render() {
|
|||||||
r.player->render();
|
r.player->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HUD: a sobre dels actors perquè la franja superior tape qualsevol
|
|
||||||
// sprite que (durant l'animació d'entrada) puga sortir més amunt
|
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
|
||||||
// del playfield. S'amaga en mode editor (l'editor edita el tilemap;
|
// notificacions i el service menu queden per damunt sense afectar-se.
|
||||||
// el HUD distrauria) però segueix actualitzant-se per fora.
|
// Inactiu si l'estat és PLAYING.
|
||||||
|
renderRoomFade();
|
||||||
|
|
||||||
|
// HUD: a sobre dels actors (i del fade) perquè la franja superior
|
||||||
|
// tape qualsevol sprite que (durant l'animació d'entrada) puga
|
||||||
|
// sortir més amunt del playfield. S'amaga en mode editor però
|
||||||
|
// segueix actualitzant-se per fora.
|
||||||
if (hud_) { hud_->render(); }
|
if (hud_) { hud_->render(); }
|
||||||
|
|
||||||
|
// Text de la transició de sala (entrada o sortida): per damunt del
|
||||||
|
// HUD perquè és UI de transició i ha de ser sempre llegible.
|
||||||
|
renderRoomText();
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "game/entities/entity_manager.hpp"
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/map/paint_layer.hpp"
|
#include "game/map/paint_layer.hpp"
|
||||||
#include "game/map/tilemap.hpp"
|
#include "game/map/tilemap.hpp"
|
||||||
|
#include "game/scenes/room_fsm.hpp"
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
#include "game/editor/editor.hpp"
|
#include "game/editor/editor.hpp"
|
||||||
@@ -45,6 +46,17 @@ class GameScene : public Scene {
|
|||||||
void render() override;
|
void render() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Punt centralitzat per canviar d'estat de sala. Imprimeix la
|
||||||
|
// transició i reinicialitza els acumuladors de la `RoomFsm::Transition`.
|
||||||
|
// Cap estat s'ha de canviar fora d'aquí.
|
||||||
|
void setState(RoomFsm::State new_state);
|
||||||
|
|
||||||
|
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
|
||||||
|
// frame amb delta real (no escalat per la pausa) perquè la transició
|
||||||
|
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
|
||||||
|
// crida `setState` (que reseteja la `Transition`).
|
||||||
|
void updateRoomTransition(int delta_ms);
|
||||||
|
|
||||||
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó
|
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó
|
||||||
// START del gamepad primari) per disparar les transicions de pausa.
|
// START del gamepad primari) per disparar les transicions de pausa.
|
||||||
// No alterna un flag binari: inicia (o inverteix mid-flight) un
|
// No alterna un flag binari: inicia (o inverteix mid-flight) un
|
||||||
@@ -56,6 +68,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_;
|
||||||
|
|
||||||
@@ -125,6 +143,50 @@ class GameScene : public Scene {
|
|||||||
UiConfig::CurveFn curve{nullptr}; // Corba activa.
|
UiConfig::CurveFn curve{nullptr}; // Corba activa.
|
||||||
} pause_;
|
} pause_;
|
||||||
|
|
||||||
|
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
|
||||||
|
// de `data/scenes/game.yaml`; valors per defecte si manca el YAML.
|
||||||
|
RoomFsm::Config transition_config_{};
|
||||||
|
|
||||||
|
// Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor
|
||||||
|
// perquè cada vegada que entres al joc passes per la presentació de
|
||||||
|
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
|
||||||
|
RoomFsm::State state_{RoomFsm::State::ROOM_INTRO};
|
||||||
|
|
||||||
|
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
|
||||||
|
// reseteja a {0, 0}.
|
||||||
|
RoomFsm::Transition transition_{};
|
||||||
|
|
||||||
|
// Índex de sala que es passa com a paràmetre {n} al text d'intro.
|
||||||
|
// Avui és sempre 1 (no hi ha itinerari de sales encara). Quan
|
||||||
|
// arribi l'itinerari, és el camp que es modificarà al transicionar
|
||||||
|
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
|
||||||
|
int room_index_{1};
|
||||||
|
|
||||||
|
// Píxels del fade negre i del text de la transició de sala.
|
||||||
|
// Cridats des de `render()` entre el bloc d'actors i el HUD —
|
||||||
|
// així marcador, notificacions i service menu queden per damunt.
|
||||||
|
void renderRoomFade();
|
||||||
|
void renderRoomText();
|
||||||
|
|
||||||
|
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
|
||||||
|
// els jugadors comparteixen Y (centre del corredor exterior superior);
|
||||||
|
// X depèn del nombre actiu: amb un jugador queda centrat sota la
|
||||||
|
// porta; amb dos, separats ±tile_size respecte al centre. Estructura
|
||||||
|
// exposada perquè els tests puguen verificar-la sense entrar al ctor
|
||||||
|
// sencer.
|
||||||
|
struct PlayerSpawn {
|
||||||
|
float p1_x{0.0F};
|
||||||
|
float p2_x{0.0F};
|
||||||
|
float y{0.0F};
|
||||||
|
};
|
||||||
|
[[nodiscard]] static auto computeSpawn() -> PlayerSpawn;
|
||||||
|
|
||||||
|
// Reset complet de la sala: reload de l'EntityManager (sarcs/porta/
|
||||||
|
// momies des de zero), neteja del PaintLayer i respawn dels jugadors
|
||||||
|
// a la posició calculada per `computeSpawn`. Cridat des de
|
||||||
|
// `setState(ROOM_INTRO)` quan la transició anterior era OUTRO.
|
||||||
|
void resetRoom();
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
|
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
|
||||||
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
|
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#include "game/scenes/room_fsm.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "core/locale/locale.hpp"
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
|
#include "external/fkyaml_node.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
|
||||||
|
// fallback. Dissenyat per al consum puntual de `loadConfig`.
|
||||||
|
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
|
||||||
|
if (!parent.contains(key)) { return fallback; }
|
||||||
|
return parent[key].get_value<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace RoomFsm {
|
||||||
|
|
||||||
|
auto loadConfig(const std::string& yaml_path) -> Config {
|
||||||
|
Config cfg{};
|
||||||
|
cfg.text_enter_curve = UiConfig::curveByName("outCubic");
|
||||||
|
cfg.text_exit_curve = UiConfig::curveByName("inCubic");
|
||||||
|
|
||||||
|
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||||||
|
if (bytes.empty()) {
|
||||||
|
std::cout << "RoomFsm: " << yaml_path << " absent — uso fallbacks compilats\n";
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||||
|
const auto* end = begin + bytes.size();
|
||||||
|
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||||
|
|
||||||
|
if (yaml.contains("room_intro")) {
|
||||||
|
const auto& n = yaml["room_intro"];
|
||||||
|
cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms);
|
||||||
|
cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms);
|
||||||
|
cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms);
|
||||||
|
cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms);
|
||||||
|
cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms);
|
||||||
|
}
|
||||||
|
if (yaml.contains("room_outro")) {
|
||||||
|
const auto& n = yaml["room_outro"];
|
||||||
|
cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms);
|
||||||
|
cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms);
|
||||||
|
cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms);
|
||||||
|
cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms);
|
||||||
|
}
|
||||||
|
if (yaml.contains("text")) {
|
||||||
|
const auto& n = yaml["text"];
|
||||||
|
if (n.contains("enter_curve")) {
|
||||||
|
cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value<std::string>());
|
||||||
|
}
|
||||||
|
if (n.contains("exit_curve")) {
|
||||||
|
cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value<std::string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "RoomFsm: timings carregats des de " << yaml_path
|
||||||
|
<< " (intro fade " << cfg.intro_fade_in_ms
|
||||||
|
<< "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n";
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
|
||||||
|
if (current == State::PLAYING) { return current; }
|
||||||
|
|
||||||
|
t.elapsed_ms += delta_ms;
|
||||||
|
|
||||||
|
if (current == State::ROOM_INTRO) {
|
||||||
|
const std::array<int, 5> DURS = {
|
||||||
|
cfg.intro_fade_in_ms,
|
||||||
|
cfg.intro_text_delay_ms,
|
||||||
|
cfg.intro_text_enter_ms,
|
||||||
|
cfg.intro_text_hold_ms,
|
||||||
|
cfg.intro_text_exit_ms,
|
||||||
|
};
|
||||||
|
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
|
||||||
|
t.elapsed_ms -= DURS[t.phase];
|
||||||
|
++t.phase;
|
||||||
|
}
|
||||||
|
if (t.phase >= static_cast<int>(DURS.size())) { return State::PLAYING; }
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROOM_OUTRO
|
||||||
|
const std::array<int, 4> DURS = {
|
||||||
|
cfg.outro_text_enter_ms,
|
||||||
|
cfg.outro_text_hold_ms,
|
||||||
|
cfg.outro_text_exit_ms,
|
||||||
|
cfg.outro_fade_out_ms,
|
||||||
|
};
|
||||||
|
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
|
||||||
|
t.elapsed_ms -= DURS[t.phase];
|
||||||
|
++t.phase;
|
||||||
|
}
|
||||||
|
// Placeholder fins a l'itinerari de sales: l'OUTRO recau a INTRO.
|
||||||
|
// TODO(itinerari): aquest mateix retorn sortirà cap a una nova
|
||||||
|
// sala (incrementant `room_index_` i recreant tilemap/entitats al
|
||||||
|
// GameScene) abans de tornar a ROOM_INTRO.
|
||||||
|
if (t.phase >= static_cast<int>(DURS.size())) { return State::ROOM_INTRO; }
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto formatRoomTitle(int n) -> std::string {
|
||||||
|
auto tpl = Locale::get().text("game.room_intro");
|
||||||
|
constexpr std::string_view PLACEHOLDER = "{n}";
|
||||||
|
auto pos = tpl.find(PLACEHOLDER);
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
|
||||||
|
}
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stateName(State s) -> const char* {
|
||||||
|
switch (s) {
|
||||||
|
case State::ROOM_INTRO:
|
||||||
|
return "ROOM_INTRO";
|
||||||
|
case State::PLAYING:
|
||||||
|
return "PLAYING";
|
||||||
|
case State::ROOM_OUTRO:
|
||||||
|
return "ROOM_OUTRO";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace RoomFsm
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/ui/ui_config.hpp"
|
||||||
|
|
||||||
|
// FSM de sala (intro/outro) extreta de GameScene per a testabilitat. La
|
||||||
|
// lògica que viu aquí és pura: no toca SDL, ni Audio, ni cap singleton
|
||||||
|
// excepte `Locale::get()` (per a la formatació del text). El consumidor
|
||||||
|
// actual és `GameScene`, que cridarà `RoomFsm::advance` cada frame.
|
||||||
|
namespace RoomFsm {
|
||||||
|
|
||||||
|
enum class State : std::uint8_t {
|
||||||
|
ROOM_INTRO, // Entrada de sala: fade in + text d'entrada.
|
||||||
|
PLAYING, // Joc normal.
|
||||||
|
ROOM_OUTRO, // Sortida de sala: text de sortida + fade out.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
|
||||||
|
// {0, 0} quan es canvia d'estat. Significat de `phase` per estat:
|
||||||
|
// ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit
|
||||||
|
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out
|
||||||
|
// PLAYING: no s'usa.
|
||||||
|
struct Transition {
|
||||||
|
int phase{0};
|
||||||
|
int elapsed_ms{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timings i corbes carregats de `data/scenes/game.yaml`. Els valors
|
||||||
|
// per defecte coincideixen amb els del YAML versionat: si manca el
|
||||||
|
// fitxer, la FSM funciona igualment amb aquests fallbacks.
|
||||||
|
struct Config {
|
||||||
|
int intro_fade_in_ms{500};
|
||||||
|
int intro_text_delay_ms{500};
|
||||||
|
int intro_text_enter_ms{500};
|
||||||
|
int intro_text_hold_ms{1000};
|
||||||
|
int intro_text_exit_ms{500};
|
||||||
|
|
||||||
|
int outro_text_enter_ms{500};
|
||||||
|
int outro_text_hold_ms{1000};
|
||||||
|
int outro_text_exit_ms{500};
|
||||||
|
int outro_fade_out_ms{500};
|
||||||
|
|
||||||
|
UiConfig::CurveFn text_enter_curve{nullptr};
|
||||||
|
UiConfig::CurveFn text_exit_curve{nullptr};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Llegeix la configuració des del YAML. Si el fitxer manca o falten
|
||||||
|
// claus, els camps no presents conserven els seus fallbacks. Les
|
||||||
|
// corbes es resolen amb `UiConfig::curveByName` (mateix mecanisme
|
||||||
|
// que la pausa); si el nom és desconegut cau a `Easing::linear`.
|
||||||
|
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
|
||||||
|
|
||||||
|
// Avança la transició consumint `delta_ms` des del temps real.
|
||||||
|
// Retorna l'estat resultant (potser el mateix que l'entrant si
|
||||||
|
// encara queden sub-fases). Si retorna un estat diferent, el caller
|
||||||
|
// ha de resetejar la `Transition` a {0, 0} abans del següent frame
|
||||||
|
// (això ho fa `GameScene::setState`). Funció pura sobre l'argument.
|
||||||
|
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
|
||||||
|
|
||||||
|
// Substitueix `{n}` de la clau "game.room_intro" del Locale per
|
||||||
|
// l'enter passat. Si la clau no està traduïda, retorna la pròpia
|
||||||
|
// clau (fallback de Locale::text).
|
||||||
|
[[nodiscard]] auto formatRoomTitle(int n) -> std::string;
|
||||||
|
|
||||||
|
// Nom textual per a logs i tests. No es fa servir per a lògica.
|
||||||
|
[[nodiscard]] auto stateName(State s) -> const char*;
|
||||||
|
|
||||||
|
} // namespace RoomFsm
|
||||||
@@ -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 {
|
||||||
@@ -218,6 +220,13 @@ namespace Defaults {
|
|||||||
} // namespace Editor
|
} // namespace Editor
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Configuració de l'escena de joc (timings i corbes de la FSM de sala).
|
||||||
|
// Independent de la piràmide concreta — quan un dia hi haja diferents
|
||||||
|
// ritmes per piràmide, aquest YAML pot adoptar variants per nivell.
|
||||||
|
namespace GameScene {
|
||||||
|
constexpr const char* CONFIG_YAML = "data/scenes/game.yaml";
|
||||||
|
} // namespace GameScene
|
||||||
|
|
||||||
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
|
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
|
||||||
// en contingui cap literal; afegir una piràmide nova serà un altre
|
// en contingui cap literal; afegir una piràmide nova serà un altre
|
||||||
// conjunt de constants + un Config distint passat al ctor.
|
// conjunt de constants + un Config distint passat al ctor.
|
||||||
|
|||||||
@@ -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.24.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
|
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
|
||||||
// passa a OPENING quan es visiten tots els tiles.
|
// passa a OPENING quan es visiten tots els tiles.
|
||||||
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
|
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
|
||||||
// s'obri sola; contactar la porta OPEN emet `level_complete`.
|
// s'obri sola. El contacte de la porta amb un jugador
|
||||||
|
// nul·l és no-op segur (la porta ja no emet `level_complete`
|
||||||
|
// directament; ho fa EntityManager comptant `player_exited`).
|
||||||
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
|
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
|
||||||
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
|
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
|
||||||
// T8 decoration — Torch no col·lisiona ni és interactable.
|
// T8 decoration — Torch no col·lisiona ni és interactable.
|
||||||
@@ -20,6 +22,10 @@
|
|||||||
// sprite sencer (pixel_rect).
|
// sprite sencer (pixel_rect).
|
||||||
// T13 mummy step — camina en línia recta pel playfield i gira en topar
|
// T13 mummy step — camina en línia recta pel playfield i gira en topar
|
||||||
// un mur (insidePlayfield=false) o sarc (Map::collides).
|
// un mur (insidePlayfield=false) o sarc (Map::collides).
|
||||||
|
// T14 exited aggregator — EntityManager compta `player_exited` i emet
|
||||||
|
// `level_complete` quan iguala `expected_players`. Verifica
|
||||||
|
// idempotència (3r event no re-emet) i reset del comptador
|
||||||
|
// via `setExpectedPlayerCount` (cicle següent torna a emetre).
|
||||||
//
|
//
|
||||||
// Execució (des de la ruta del repo):
|
// Execució (des de la ruta del repo):
|
||||||
// make build && cd build && ctest --output-on-failure
|
// make build && cd build && ctest --output-on-failure
|
||||||
@@ -173,12 +179,6 @@ namespace {
|
|||||||
manager.add(std::move(door));
|
manager.add(std::move(door));
|
||||||
manager.setDoor(door_ptr);
|
manager.setDoor(door_ptr);
|
||||||
|
|
||||||
// Subscriptor local per capturar `level_complete`.
|
|
||||||
bool level_complete_fired = false;
|
|
||||||
manager.getBus().subscribe("level_complete", [&](const Event&) {
|
|
||||||
level_complete_fired = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
|
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
|
||||||
auto forceOpen = [](Sarcophagus* s) {
|
auto forceOpen = [](Sarcophagus* s) {
|
||||||
const int COL0 = s->getTilePos().col;
|
const int COL0 = s->getTilePos().col;
|
||||||
@@ -206,12 +206,16 @@ namespace {
|
|||||||
REQUIRE(manager.openedSarcophagusCount() == 2);
|
REQUIRE(manager.openedSarcophagusCount() == 2);
|
||||||
REQUIRE(door_ptr->getState() == Door::State::OPEN);
|
REQUIRE(door_ptr->getState() == Door::State::OPEN);
|
||||||
|
|
||||||
// Contacte amb la porta oberta → level_complete.
|
// Contacte sense jugador (nullptr): no-op segur. La porta ja no
|
||||||
door_ptr->onPlayerContact(nullptr);
|
// emet `level_complete` directament — ara és EntityManager qui
|
||||||
REQUIRE(level_complete_fired);
|
// l'emet quan tots els jugadors han eixit. La cobertura
|
||||||
|
// d'aquest agregat viu al test específic d'EntityManager
|
||||||
// Un segon contacte NO torna a disparar (idempotent).
|
// (test_game_fsm) perquè requereix instanciar Player, fora
|
||||||
level_complete_fired = false;
|
// d'abast d'aquest smoke purament d'entitats.
|
||||||
|
bool level_complete_fired = false;
|
||||||
|
manager.getBus().subscribe("level_complete", [&](const Event&) {
|
||||||
|
level_complete_fired = true;
|
||||||
|
});
|
||||||
door_ptr->onPlayerContact(nullptr);
|
door_ptr->onPlayerContact(nullptr);
|
||||||
REQUIRE(!level_complete_fired);
|
REQUIRE(!level_complete_fired);
|
||||||
}
|
}
|
||||||
@@ -449,6 +453,46 @@ namespace {
|
|||||||
REQUIRE(torch.getAnimation()->currentFrame() == 2);
|
REQUIRE(torch.getAnimation()->currentFrame() == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testExitedAggregator() {
|
||||||
|
std::cout << "[T14] EntityManager: player_exited → level_complete\n";
|
||||||
|
EntityManager manager;
|
||||||
|
|
||||||
|
// Comptador inicialitzat per a 2 jugadors. Sense aquesta crida,
|
||||||
|
// l'agregador no faria res (expected_players_ = 0).
|
||||||
|
manager.setExpectedPlayerCount(2);
|
||||||
|
|
||||||
|
int level_complete_count = 0;
|
||||||
|
manager.getBus().subscribe("level_complete", [&](const Event& ev) {
|
||||||
|
++level_complete_count;
|
||||||
|
// Event d'agregat: no atribuïble a un jugador concret.
|
||||||
|
REQUIRE(ev.player_id == -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Primer player_exited: comptador a 1 < 2, no emet encara.
|
||||||
|
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||||||
|
REQUIRE(manager.exitedPlayerCount() == 1);
|
||||||
|
REQUIRE(level_complete_count == 0);
|
||||||
|
|
||||||
|
// Segon player_exited: comptador a 2 == expected, emet una vegada.
|
||||||
|
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
|
||||||
|
REQUIRE(manager.exitedPlayerCount() == 2);
|
||||||
|
REQUIRE(level_complete_count == 1);
|
||||||
|
|
||||||
|
// Un tercer player_exited (no hauria de passar amb 2 jugadors,
|
||||||
|
// però defensa contra publicacions extra): no torna a emetre.
|
||||||
|
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||||||
|
REQUIRE(level_complete_count == 1);
|
||||||
|
|
||||||
|
// setExpectedPlayerCount torna a obrir el cicle (això simularia
|
||||||
|
// el que fa GameScene després d'un reload de sala). El comptador
|
||||||
|
// d'EXITED es reinicia a 0 i el flag d'emissió també.
|
||||||
|
manager.setExpectedPlayerCount(2);
|
||||||
|
REQUIRE(manager.exitedPlayerCount() == 0);
|
||||||
|
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||||||
|
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
|
||||||
|
REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
auto main() -> int {
|
auto main() -> int {
|
||||||
@@ -467,6 +511,7 @@ auto main() -> int {
|
|||||||
testCollisionBox();
|
testCollisionBox();
|
||||||
testMummyStep();
|
testMummyStep();
|
||||||
testTorchAnimation();
|
testTorchAnimation();
|
||||||
|
testExitedAggregator();
|
||||||
|
|
||||||
if (g_failures == 0) {
|
if (g_failures == 0) {
|
||||||
std::cout << "ALL OK\n";
|
std::cout << "ALL OK\n";
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Smoke test de la FSM de sala (RoomFsm) extreta de GameScene. Cobreix:
|
||||||
|
// T1 loadConfig — fitxer real → tots els camps llegits.
|
||||||
|
// T2 loadConfig — fitxer absent → fallbacks compilats; corbes default
|
||||||
|
// resoltes per UiConfig::curveByName.
|
||||||
|
// T3 loadConfig — claus parcials → camps absents conserven fallback.
|
||||||
|
// T4 advance — ROOM_INTRO consumeix les 5 sub-fases en ordre.
|
||||||
|
// T5 advance — ROOM_INTRO desemboca a PLAYING quan acaba l'última fase.
|
||||||
|
// T6 advance — PLAYING és sumidor: no avança res ni canvia d'estat.
|
||||||
|
// T7 advance — ROOM_OUTRO consumeix les 4 sub-fases i recau a ROOM_INTRO.
|
||||||
|
// T8 advance — un delta gran salta més d'una sub-fase en un sol pas.
|
||||||
|
// T9 formatRoomTitle — substitueix `{n}` quan el Locale té la clau.
|
||||||
|
// T10 formatRoomTitle — sense Locale, retorna la pròpia clau (fallback).
|
||||||
|
// T11 stateName — strings esperats per a cada State.
|
||||||
|
//
|
||||||
|
// El render no es testeja: la FSM és pura i no pinta res. Les regles de
|
||||||
|
// gating d'input dels jugadors viuen a `GameScene::update` i estan
|
||||||
|
// cobertes pel smoke run del binari (xvfb-run) — la part testable és
|
||||||
|
// que `advance` retorna l'estat correcte, que és el senyal que activa
|
||||||
|
// el gating al callsite.
|
||||||
|
//
|
||||||
|
// Execució:
|
||||||
|
// make build && cd build && ctest --output-on-failure
|
||||||
|
// O bé:
|
||||||
|
// ./build/test_game_fsm
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/locale/locale.hpp"
|
||||||
|
#include "core/ui/ui_config.hpp"
|
||||||
|
#include "game/scenes/room_fsm.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
void check(bool cond, const char* msg, const char* file, int line) {
|
||||||
|
if (!cond) {
|
||||||
|
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
|
||||||
|
|
||||||
|
auto writeTemp(const std::string& path, const std::string& content) -> std::string {
|
||||||
|
std::ofstream file(path);
|
||||||
|
file << content;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLoadConfigFull() {
|
||||||
|
std::cout << "T1 loadConfig — fitxer real complet\n";
|
||||||
|
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_full.yaml").string();
|
||||||
|
writeTemp(PATH,
|
||||||
|
"room_intro:\n"
|
||||||
|
" fade_in_ms: 100\n"
|
||||||
|
" text_delay_ms: 200\n"
|
||||||
|
" text_enter_ms: 300\n"
|
||||||
|
" text_hold_ms: 400\n"
|
||||||
|
" text_exit_ms: 500\n"
|
||||||
|
"room_outro:\n"
|
||||||
|
" text_enter_ms: 600\n"
|
||||||
|
" text_hold_ms: 700\n"
|
||||||
|
" text_exit_ms: 800\n"
|
||||||
|
" fade_out_ms: 900\n"
|
||||||
|
"text:\n"
|
||||||
|
" enter_curve: outQuad\n"
|
||||||
|
" exit_curve: inQuad\n");
|
||||||
|
const auto CFG = RoomFsm::loadConfig(PATH);
|
||||||
|
CHECK(CFG.intro_fade_in_ms == 100);
|
||||||
|
CHECK(CFG.intro_text_delay_ms == 200);
|
||||||
|
CHECK(CFG.intro_text_enter_ms == 300);
|
||||||
|
CHECK(CFG.intro_text_hold_ms == 400);
|
||||||
|
CHECK(CFG.intro_text_exit_ms == 500);
|
||||||
|
CHECK(CFG.outro_text_enter_ms == 600);
|
||||||
|
CHECK(CFG.outro_text_hold_ms == 700);
|
||||||
|
CHECK(CFG.outro_text_exit_ms == 800);
|
||||||
|
CHECK(CFG.outro_fade_out_ms == 900);
|
||||||
|
CHECK(CFG.text_enter_curve == UiConfig::curveByName("outQuad"));
|
||||||
|
CHECK(CFG.text_exit_curve == UiConfig::curveByName("inQuad"));
|
||||||
|
std::filesystem::remove(PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLoadConfigMissing() {
|
||||||
|
std::cout << "T2 loadConfig — fitxer absent (fallbacks)\n";
|
||||||
|
const auto CFG = RoomFsm::loadConfig("/tmp/aee_2026_does_not_exist.yaml");
|
||||||
|
CHECK(CFG.intro_fade_in_ms == 500);
|
||||||
|
CHECK(CFG.intro_text_delay_ms == 500);
|
||||||
|
CHECK(CFG.intro_text_enter_ms == 500);
|
||||||
|
CHECK(CFG.intro_text_hold_ms == 1000);
|
||||||
|
CHECK(CFG.intro_text_exit_ms == 500);
|
||||||
|
CHECK(CFG.outro_text_enter_ms == 500);
|
||||||
|
CHECK(CFG.outro_text_hold_ms == 1000);
|
||||||
|
CHECK(CFG.outro_text_exit_ms == 500);
|
||||||
|
CHECK(CFG.outro_fade_out_ms == 500);
|
||||||
|
CHECK(CFG.text_enter_curve != nullptr);
|
||||||
|
CHECK(CFG.text_exit_curve != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLoadConfigPartial() {
|
||||||
|
std::cout << "T3 loadConfig — claus parcials (mantenir fallback)\n";
|
||||||
|
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_partial.yaml").string();
|
||||||
|
writeTemp(PATH,
|
||||||
|
"room_intro:\n"
|
||||||
|
" fade_in_ms: 42\n");
|
||||||
|
const auto CFG = RoomFsm::loadConfig(PATH);
|
||||||
|
CHECK(CFG.intro_fade_in_ms == 42);
|
||||||
|
CHECK(CFG.intro_text_delay_ms == 500); // fallback
|
||||||
|
CHECK(CFG.outro_fade_out_ms == 500); // fallback
|
||||||
|
std::filesystem::remove(PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto makeShortConfig() -> RoomFsm::Config {
|
||||||
|
// Durades curtes per fer els tests d'`advance` deterministes i
|
||||||
|
// independents dels valors per defecte.
|
||||||
|
RoomFsm::Config cfg{};
|
||||||
|
cfg.intro_fade_in_ms = 10;
|
||||||
|
cfg.intro_text_delay_ms = 20;
|
||||||
|
cfg.intro_text_enter_ms = 30;
|
||||||
|
cfg.intro_text_hold_ms = 40;
|
||||||
|
cfg.intro_text_exit_ms = 50;
|
||||||
|
cfg.outro_text_enter_ms = 11;
|
||||||
|
cfg.outro_text_hold_ms = 22;
|
||||||
|
cfg.outro_text_exit_ms = 33;
|
||||||
|
cfg.outro_fade_out_ms = 44;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAdvanceIntroPhases() {
|
||||||
|
std::cout << "T4 advance — ROOM_INTRO consumeix sub-fases en ordre\n";
|
||||||
|
const auto CFG = makeShortConfig();
|
||||||
|
RoomFsm::Transition t{};
|
||||||
|
auto s = RoomFsm::State::ROOM_INTRO;
|
||||||
|
|
||||||
|
s = RoomFsm::advance(s, t, CFG, 5); // dins fase 0
|
||||||
|
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 0 && t.elapsed_ms == 5);
|
||||||
|
|
||||||
|
s = RoomFsm::advance(s, t, CFG, 10); // surt de fase 0, entra a fase 1 (5 ms dins)
|
||||||
|
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 1 && t.elapsed_ms == 5);
|
||||||
|
|
||||||
|
s = RoomFsm::advance(s, t, CFG, 20); // surt de fase 1 (15 dins), entra a fase 2 amb 5 ms (5+20-20=5)
|
||||||
|
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 2 && t.elapsed_ms == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAdvanceIntroToPlaying() {
|
||||||
|
std::cout << "T5 advance — ROOM_INTRO acaba a PLAYING\n";
|
||||||
|
const auto CFG = makeShortConfig();
|
||||||
|
RoomFsm::Transition t{};
|
||||||
|
const int TOTAL = CFG.intro_fade_in_ms + CFG.intro_text_delay_ms +
|
||||||
|
CFG.intro_text_enter_ms + CFG.intro_text_hold_ms +
|
||||||
|
CFG.intro_text_exit_ms;
|
||||||
|
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, TOTAL);
|
||||||
|
CHECK(S == RoomFsm::State::PLAYING);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAdvancePlayingNoOp() {
|
||||||
|
std::cout << "T6 advance — PLAYING és sumidor\n";
|
||||||
|
const auto CFG = makeShortConfig();
|
||||||
|
RoomFsm::Transition t{42, 999};
|
||||||
|
const auto S = RoomFsm::advance(RoomFsm::State::PLAYING, t, CFG, 1000);
|
||||||
|
CHECK(S == RoomFsm::State::PLAYING);
|
||||||
|
CHECK(t.phase == 42); // intacte
|
||||||
|
CHECK(t.elapsed_ms == 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAdvanceOutroToIntro() {
|
||||||
|
std::cout << "T7 advance — ROOM_OUTRO recau a ROOM_INTRO\n";
|
||||||
|
const auto CFG = makeShortConfig();
|
||||||
|
RoomFsm::Transition t{};
|
||||||
|
const int TOTAL = CFG.outro_text_enter_ms + CFG.outro_text_hold_ms +
|
||||||
|
CFG.outro_text_exit_ms + CFG.outro_fade_out_ms;
|
||||||
|
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_OUTRO, t, CFG, TOTAL);
|
||||||
|
CHECK(S == RoomFsm::State::ROOM_INTRO);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAdvanceLargeDeltaJumps() {
|
||||||
|
std::cout << "T8 advance — un delta gran salta múltiples sub-fases\n";
|
||||||
|
const auto CFG = makeShortConfig();
|
||||||
|
RoomFsm::Transition t{};
|
||||||
|
// 10+20+5 = 35 ms → consumeix fase 0 i 1, queda a fase 2 amb 5 ms.
|
||||||
|
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, 35);
|
||||||
|
CHECK(S == RoomFsm::State::ROOM_INTRO);
|
||||||
|
CHECK(t.phase == 2);
|
||||||
|
CHECK(t.elapsed_ms == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testFormatRoomTitleWithLocale() {
|
||||||
|
std::cout << "T9 formatRoomTitle — substitució amb Locale\n";
|
||||||
|
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale.yaml").string();
|
||||||
|
writeTemp(PATH, "game:\n room_intro: \"Sala {n}\"\n");
|
||||||
|
Locale::get().load(PATH);
|
||||||
|
CHECK(RoomFsm::formatRoomTitle(1) == "Sala 1");
|
||||||
|
CHECK(RoomFsm::formatRoomTitle(7) == "Sala 7");
|
||||||
|
std::filesystem::remove(PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testFormatRoomTitleNoLocale() {
|
||||||
|
std::cout << "T10 formatRoomTitle — fallback sense traducció\n";
|
||||||
|
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale_empty.yaml").string();
|
||||||
|
writeTemp(PATH, "other: foo\n");
|
||||||
|
Locale::get().load(PATH);
|
||||||
|
// Sense la clau "game.room_intro", Locale retorna la pròpia clau,
|
||||||
|
// i com que no porta `{n}`, la substitució no canvia res.
|
||||||
|
CHECK(RoomFsm::formatRoomTitle(3) == "game.room_intro");
|
||||||
|
std::filesystem::remove(PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testStateName() {
|
||||||
|
std::cout << "T11 stateName — strings esperats\n";
|
||||||
|
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_INTRO)) == "ROOM_INTRO");
|
||||||
|
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::PLAYING)) == "PLAYING");
|
||||||
|
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_OUTRO)) == "ROOM_OUTRO");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto main() -> int {
|
||||||
|
testLoadConfigFull();
|
||||||
|
testLoadConfigMissing();
|
||||||
|
testLoadConfigPartial();
|
||||||
|
testAdvanceIntroPhases();
|
||||||
|
testAdvanceIntroToPlaying();
|
||||||
|
testAdvancePlayingNoOp();
|
||||||
|
testAdvanceOutroToIntro();
|
||||||
|
testAdvanceLargeDeltaJumps();
|
||||||
|
testFormatRoomTitleWithLocale();
|
||||||
|
testFormatRoomTitleNoLocale();
|
||||||
|
testStateName();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::cout << "OK: tots els tests de RoomFsm passen\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::cerr << g_failures << " tests han fallat\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||